useGLTF
drei가 GLTFLoader에 Draco/Meshopt 지원과 preload를 얹어 놓은 훅이다.
1const { scene, nodes, materials, animations, cameras, asset } = useGLTF(url);
돌려주는 값은 이렇다.
scene: 루트 Object3D
nodes: 이름으로 접근 가능한 객체 맵
materials: 재질 맵
animations: AnimationClip 배열
1import { useGLTF } from '@react-three/drei';
2
3function Ship() {
4 const { scene } = useGLTF('/models/test.glb');
5 return <primitive object={scene} />;
6}
7
8useGLTF.preload('/models/test.glb'); // 모듈 로드 시점에 미리 받기
preload는 컴포넌트 바깥, 모듈 최상단에 둔다. 컴포넌트가 마운트되기 전에 다운로드를 시작하므로, 사용자가 그 화면에 도달했을 때 이미 받아져 있게 된다.
primitive의 한계
primitive로 scene을 그대로 쓰는 것이 가장 간단하지만 제약이 있다.
캐시된 객체를 직접 사용한다
useGLTF는 같은 URL에 대해 같은 인스턴스를 돌려준다. 그래서 같은 모델을 두 곳에서 렌더링하면 하나만 나타난다. Object3D는 부모를 하나만 가지므로, 나중에 마운트된 쪽으로 옮겨가버린다.
내부를 제어할 수 없다
특정 mesh에만 그림자를 켜거나 재질을 바꾸려면 traverse로 명령형 처리를 해야 한다. 선언형으로 쓰려고 R3F를 골랐는데 결국 명령형 코드가 섞인다.
캐시가 오염될 수 있다
한 컴포넌트에서 색을 바꾸면 같은 모델을 쓰는 모든 곳이 바뀐다. 캐시된 재질 인스턴스를 공유하고 있기 때문이다.
Clone
같은 모델을 여러 개 배치한다면 Clone을 쓴다.
1import { Clone, useGLTF } from '@react-three/drei';
2
3const { scene } = useGLTF('/models/tree.glb');
4
5return (
6 <>
7 {positions.map((p, i) => <Clone key={i} object={scene} position={p} />)}
8 </>
9);
Clone은 씬 그래프 구조는 복제하되 geometry와 material은 원본을 참조한다. 그래서 100그루의 나무를 배치해도 GPU 메모리는 한 그루 분량이다.
연습 프로젝트에서도 이 형태로 감쌌다.
1import { Clone, useGLTF } from '@react-three/drei';
2import type { ThreeElements } from '@react-three/fiber';
3
4type LlamaProps = ThreeElements['group'];
5
6export const Llama = (props: LlamaProps) => {
7 const { scene } = useGLTF('/llama.glb');
8
9 return (
10 <group {...props}>
11 <Clone object={scene} />
12 </group>
13 );
14};
주의할 점은 뼈대가 있는 모델은 Clone으로 부족하다는 것이다. 스켈레톤과 애니메이션 믹서가 복제본마다 독립적이어야 하기 때문에, SkeletonUtils.clone을 써야 한다. 이 부분은 Animation에 정리했다.
gltfjsx
GLB를 JSX 소스 코드로 변환해주는 도구다. 이건 정말 편하다.
1npx gltfjsx test.glb --types --transform --resolution 1024 --simplify
얻는 것이 세 가지다.
- 씬 그래프가 JSX로 펼쳐져서 각 mesh를 개별적으로 제어할 수 있다.
- geometry와 material은 캐시에서 참조만 하므로 인스턴스를 여러 개 만들어도 안전하다.
--types를 주면 TypeScript 타입이 생성된다.
--transform은 gltf-transform을 태워서 모델 자체를 최적화한 파일을 함께 만들어준다. 연습에 쓴 라마 모델은 291.9KB에서 173.41KB로 줄었다.
변환된 결과는 이런 모양이다.
1/*
2Auto-generated by: https://github.com/pmndrs/gltfjsx
3Command: npx gltfjsx@6.5.3 llama.glb --types --transform
4*/
5
6type GLTFResult = GLTF & {
7 nodes: {
8 Cylinder: THREE.SkinnedMesh;
9 Cylinder_1: THREE.SkinnedMesh;
10 Cylinder_2: THREE.SkinnedMesh;
11 root: THREE.Bone;
12 };
13 materials: {
14 Brown: THREE.MeshStandardMaterial;
15 };
16 animations: GLTFAction[];
17};
18
19export function Model(props: JSX.IntrinsicElements['group']) {
20 const group = React.useRef<THREE.Group>();
21 const { scene, animations } = useGLTF('/llama-transformed.glb');
22 const clone = React.useMemo(() => SkeletonUtils.clone(scene), [scene]);
23 const { nodes, materials } = useGraph(clone) as GLTFResult;
24 const { actions } = useAnimations(animations, group);
25
26 return (
27 <group ref={group} {...props} dispose={null}>
28 <group name='Scene'>
29 <group name='Armature'>
30 <primitive object={nodes.root} />
31 </group>
32 <group name='Llama'>
33 <skinnedMesh
34 name='Cylinder'
35 geometry={nodes.Cylinder.geometry}
36 material={materials.Brown}
37 skeleton={nodes.Cylinder.skeleton}
38 castShadow
39 receiveShadow
40 />
41 {/* ... */}
42 </group>
43 </group>
44 </group>
45 );
46}
47
48useGLTF.preload('/llama-transformed.glb');
생성된 코드에서 눈여겨볼 부분이 몇 개 있다.
dispose={null}: 캐시된 리소스를 참조만 하므로 자동 해제를 끈다.
SkeletonUtils.clone + useGraph: 스켈레톤을 복제하고, 복제본에서 다시 nodes/materials 맵을 만든다. 그래서 이 컴포넌트는 여러 개 배치해도 각자 독립적으로 움직인다.
ActionName 타입: 이 모델이 가진 클립 이름이 'Death' | 'Idle' | 'Jump' | 'Run' | 'Walk' | 'WalkSlow'로 타입에 박힌다. 오타를 컴파일 단계에서 잡을 수 있다.
생성된 파일은 그대로 두지 말고 손대는 것을 전제로 본다. 그림자 설정, 재질 교체, 이벤트 핸들러를 여기에 붙이면 된다. 다만 모델이 바뀌어 다시 생성하면 수정한 내용이 날아가므로, 많이 손댈 거라면 생성 파일은 그대로 두고 감싸는 컴포넌트를 따로 만드는 편이 낫다.
압축
지오메트리
- Draco: 정점 데이터를 압축한다. 압축률은 높지만 디코더가 필요하고 디코딩 시간이 발생한다.
- Meshopt: Draco보다 압축률은 낮지만 디코딩이 훨씬 빠르다.
모델이 크고 네트워크가 병목이면 Draco, 디코딩 지연이 거슬리면 Meshopt을 고른다. 모바일에서는 디코딩 비용이 무시 못 할 수준이라 Meshopt이 유리한 경우가 많다.
텍스처
- KTX2: GPU가 압축된 상태로 읽는 포맷. VRAM이 최대 1/6로 줄어든다.
Textures에서 정리했듯이 JPG나 PNG는 아무리 압축해도 GPU에 올라갈 때는 압축이 풀린다. 전송량만 줄어들 뿐 VRAM은 해상도로 결정된다. KTX2는 압축된 채로 올라가기 때문에 실제로 메모리가 줄어드는 유일한 방법이다.
1import { KTX2Loader } from 'three/examples/jsm/loaders/KTX2Loader.js';
2
3// useLoader 세 번째 인자에서 설정
4const gltf = useLoader(GLTFLoader, '/ship.glb', (loader) => {
5 const ktx2 = new KTX2Loader().setTranscoderPath('/basis/').detectSupport(gl);
6 loader.setKTX2Loader(ktx2);
7});
glb를 변환하는 명령은 이렇다.
1npx @gltf-transform/cli optimize input.glb output.glb --texture-compress ktx2
최적화 우선순위
- 텍스처 해상도 줄이기
- KTX2로 변환
- 지오메트리 압축
- 폴리곤 단순화
순서에 이유가 있다. 대부분의 모델에서 용량과 메모리를 지배하는 것은 텍스처지 정점이 아니다. 4096 텍스처 한 장이 64MB인데 정점 데이터는 보통 몇 MB다. 폴리곤 단순화가 마지막인 것은 눈에 보이는 품질 손해가 가장 크기 때문이다.
useTexture
텍스처만 따로 다룰 때 쓴다.
1import { useTexture } from '@react-three/drei';
2
3// 단일
4const map = useTexture('/wood_color.jpg');
5
6// 여러 개 — 객체 형태가 편하다
7const props = useTexture({
8 map: '/wood_color.jpg',
9 normalMap: '/wood_normal.jpg',
10 roughnessMap: '/wood_rough.jpg',
11 aoMap: '/wood_ao.jpg',
12});
13
14return <meshStandardMaterial {...props} />;
객체 형태로 주면 키가 그대로 재질의 prop 이름이 되어 스프레드로 넘길 수 있다. 이게 꽤 편하다.
반환된 텍스처는 three.js 객체 그대로라, 랩 모드나 반복 설정은 직접 만진다.
1const texture = useTexture('/tiles.jpg');
2texture.wrapS = texture.wrapT = THREE.RepeatWrapping;
3texture.repeat.set(10, 10);
4texture.anisotropy = gl.capabilities.getMaxAnisotropy(); // 비스듬한 각도의 선명도
여기서도 캐시 공유 문제가 그대로 나온다. 같은 URL의 텍스처는 같은 인스턴스이므로, 위처럼 repeat을 바꾸면 그 텍스처를 쓰는 모든 곳이 바뀐다. 바닥마다 다른 반복 횟수가 필요하다면 texture.clone()을 쓴다.
colorSpace 설정도 잊기 쉽다. useTexture는 색상 텍스처를 자동으로 sRGB로 잡아주지만, normal map이나 roughness map을 map 자리가 아닌 곳에 넣을 때는 직접 확인하는 편이 안전하다.
Suspense
useGLTF와 useTexture는 Suspense 기반이므로 경계가 필요하다. 경계를 어디에 두느냐가 로딩 경험을 결정한다.
1<Canvas>
2 <Suspense fallback={null}><Terrain /></Suspense>
3 <Suspense fallback={null}><Ships /></Suspense>
4 <Suspense fallback={null}><Buildings /></Suspense>
5</Canvas>
경계를 나누면 먼저 받아진 것부터 순서대로 나타난다. 하나로 묶으면 가장 느린 하나가 끝날 때까지 아무것도 보이지 않는다.
ErrorBoundary
Suspense는 로딩만 처리하고 실패는 처리하지 못한다. 모델 URL이 404거나 파일이 깨졌을 때 앱 전체가 죽지 않게 하려면 에러 경계가 필요하다.
1<ErrorBoundary fallback={<FallbackBox />}>
2 <Suspense fallback={<Loader />}>
3 <Model />
4 </Suspense>
5</ErrorBoundary>
FallbackBox처럼 자리를 대신할 단순한 도형을 두면 배치가 무너지지 않는다.
진행률
1import { useProgress, Html } from '@react-three/drei';
2
3function Loader() {
4 const { progress, loaded, total, item } = useProgress();
5
6 return (
7 <Html center>
8 <div style={{ color: 'white' }}>
9 {progress.toFixed(0)}% ({loaded}/{total})
10 </div>
11 </Html>
12 );
13}
useProgress는 three.js의 기본 LoadingManager를 구독한다. 연습에서는 최소한으로만 만들었다.
1import { Html, useProgress } from '@react-three/drei';
2
3export const Loader = () => {
4 const { progress } = useProgress();
5
6 return (
7 <Html center>
8 <div style={{ color: '#fff', fontSize: 13, fontVariantNumeric: 'tabular-nums' }}>
9 {progress.toFixed(0)}%
10 </div>
11 </Html>
12 );
13};
fontVariantNumeric: 'tabular-nums'는 숫자 폭을 고정하는 설정이다. 없으면 퍼센트가 올라가는 동안 글자가 좌우로 떨린다.
drei에는 캔버스 밖에 두는 완성형 Loader도 들어 있다.
1import { Loader } from '@react-three/drei';
2
3<>
4 <Canvas>...</Canvas>
5 <Loader /> {/* Canvas 밖에 배치 */}
6</>
Html 기반 로더는 Canvas 안에 있어야 하고, drei의 Loader는 밖에 둔다는 점이 다르다. 안쪽 로더는 3D 좌표에 붙일 수 있어서 "이 자리에 무언가 로딩 중"을 표현하기 좋고, 바깥 로더는 전체 화면 오버레이에 적합하다.